C++ 상태 관리 패턴 lager 안내서

C++ 상태 관리 패턴 lager 안내서

1. 현대 C++ 애플리케이션과 상태 관리의 도전

1.1 전통적 접근법의 균열: MVC와 객체 지향의 명암

지난 수십 년간 대화형(interactive) 소프트웨어 개발의 주류는 모델-뷰-컨트롤러(Model-View-Controller, MVC) 아키텍처의 객체 지향적 해석에 기반을 두어 왔다.1 MVC는 애플리케이션의 핵심 로직(Model)을 사용자 인터페이스(View)로부터 분리함으로써 관심사 분리(separation of concerns)라는 명확한 구조적 이점을 제공했다. 그러나 애플리케이션의 규모가 커지고 복잡성이 증가함에 따라, 이 전통적 접근법은 명확한 한계를 드러내기 시작했다.

가장 큰 문제는 상태를 가진 객체 그래프(stateful object graphs)에 대한 깊은 의존성에서 비롯된다. 시스템의 상태가 여러 객체에 분산되어 있고, 이 객체들이 서로를 직접 수정할 수 있는 구조는 예측 불가능한 상태 전이를 유발한다. 하나의 변화가 시스템 전체에 예기치 않은 연쇄 반응(cascading effects)을 일으킬 수 있으며, 이는 디버깅을 극도로 어렵게 만든다. 또한, 세분화된 콜백(fine-grained callbacks) 메커니즘은 구성(composition)을 어렵게 만들어 미묘한 버그의 온상이 되곤 한다.1 이러한 구조는 본질적으로 테스트와 병렬화를 어렵게 만든다. 특정 상태를 재현하여 테스트하기 어렵고, 공유된 가변 상태(shared mutable state)는 데이터 경쟁(data race)과 교착 상태(deadlock)의 위험을 내포하기 때문이다.

특히 Qt나 Juce와 같은 강력한 C++ 프레임워크는 그 자체로 훌륭하지만, 시스템의 경계에서 자바와 유사한 객체 지향 접근법을 강제하는 경향이 있다.2 이는 값 기반(value-oriented) 설계를 점진적으로 도입하려는 시도에 장벽으로 작용하며, 개발자들을 전통적인 패턴의 한계 안에 머물게 한다.

1.2 패러다임의 전환: 값 지향 설계와 단방향 데이터 흐름

이러한 전통적 패러다임의 한계를 극복하기 위한 대안으로 Elm, Redux와 같은 함수형 프로그래밍 패러다임에서 영감을 받은 새로운 아키텍처가 부상했다.1 이 패러다임의 핵심에는 값 지향 설계(Value-Oriented Design), 불변성(Immutability), 그리고 **단방향 데이터 흐름(Unidirectional Data Flow)**이라는 세 가지 원칙이 자리 잡고 있다.

값 지향 설계는 데이터의 의미를 그 값이 표현하는 내용 자체에 두는 방식이다.4 각 인스턴스는 독립적인 값을 가지며, 참조가 아닌 값으로 다루어진다. 불변성은 한번 생성된 데이터는 결코 수정되지 않는다는 원칙이다.5 상태에 변화가 필요할 경우, 기존 상태를 직접 수정(mutate)하는 대신 변화가 적용된 새로운 상태 값을 생성한다.6

이 두 원칙은 단방향 데이터 흐름 아키텍처의 근간을 이룬다. 데이터의 흐름이 항상 View -> Action -> State -> View라는 하나의 예측 가능한 경로로 고정된다.8 사용자의 입력(View)은 상태 변화의 의도를 담은 명시적인 메시지(Action)를 발생시킨다. 이 Action은 시스템의 현재 상태(State)를 기반으로 새로운 상태를 계산하는 로직을 거쳐, 업데이트된 상태가 다시 View에 반영된다. 이러한 흐름은 상태 변화의 원인과 결과를 명확하게 추적할 수 있게 하여, 시스템의 동작을 이해하고 디버깅하는 것을 획기적으로 단순화한다.9

1.3 lager: C++를 위한 Redux

lager는 바로 이러한 현대적 상태 관리 패러다임을 C++ 환경에 성공적으로 이식한 라이브러리다.1

lager는 단순히 상태를 저장하는 컨테이너를 제공하는 것을 넘어, 애플리케이션의 복잡성을 근본적으로 제어하기 위한 아키텍처 철학의 구현체로서 기능한다. Redux와 Elm에서 강하게 영감을 받은 lager는 단순한 값 타입과 부수 효과가 없는 순수 함수(pure functions)를 통해 조합 가능하고(composable), 테스트하기 쉬우며(testable), 예측 가능한(predictable) 애플리케이션 로직 작성을 장려한다.1

전통적인 MVC 패턴이 ’어떻게’에 초점을 맞춘 절차적 명령의 연속이라면, lager는 ’무엇’이 변했는지를 선언적으로 기술하는 방식에 가깝다. 상태 변화의 흐름 자체를 통제하여 예측 가능성을 확보하고, 순수 함수를 통해 로직을 외부 세계로부터 격리하여 테스트 용이성을 극대화한다. 이는 라이브러리를 단순한 ’도구’가 아닌, 애플리케이션 설계를引导하는 ‘프레임워크’ 또는 ’설계 지침’으로 바라보는 관점의 전환을 요구한다.

본 안내서는 lager의 철학적 배경부터 네 가지 핵심 구성요소의 역할, 실전 예제를 통한 구현 방법, 그리고 고급 활용법에 이르기까지 심층적으로 분석하여, C++ 상태 관리의 새로운 지평을 제시하고자 한다.

2. Lager 아키텍처의 해부: 네 가지 핵심 구성요소

lager 아키텍처는 상태(State/Model), 액션(Action), 리듀서(Reducer), 스토어(Store)라는 네 가지 핵심 구성요소의 상호작용으로 이루어진다. 이 구성요소들은 Redux의 개념과 거의 일대일로 대응되며, 단방향 데이터 흐름을 형성하는 각자의 명확한 역할을 수행한다.

2.1 상태 (State/Model): 유일한 진실의 원천 (Single Source of Truth)

  • 정의: 모델(Model)은 특정 시점의 애플리케이션 상태 전체에 대한 스냅샷을 담고 있는 값 타입(value type)이다. 일반적으로 C++의 struct 또는 class로 정의된다.11

  • 역할: lager 아키텍처에서 가장 중요한 원칙 중 하나는 ’유일한 진실의 원천(Single Source of Truth)’이다. 애플리케이션의 모든 상태 정보가 단 하나의 데이터 구조, 즉 모델에 집중되어 있어 상태 불일치로 인한 문제를 원천적으로 방지한다.9 UI 컴포넌트나 비즈니스 로직의 여러 부분이 각자 자신만의 상태를 유지하는 대신, 모두 이 중앙 모델을 구독하고 그로부터 데이터를 얻는다.

  • 설계 원칙: 모델은 반드시 값 의미론(value semantics)을 가져야 한다. 이는 모델 객체를 복사했을 때, 원본과 완전히 독립적인 새로운 인스턴스가 생성되어야 함을 의미한다. 따라서 모델은 포인터, 참조, 또는 외부의 가변 상태를 참조하는 핸들 같은 참조 타입(reference type)을 멤버로 포함해서는 안 된다.4

std::vector<T>와 같은 컨테이너가 모델의 일부가 되려면, 그 요소 타입 T 역시 값 타입이어야 한다.

2.2 액션 (Action): 상태 변화의 유일한 매개체

  • 정의: 액션(Action)은 시스템에서 발생한 이벤트나 사용자의 의도(intent)를 기술하는 간단한 값 타입이다. 액션은 상태를 ‘어떻게’ 바꿀지에 대한 구체적인 로직을 담고 있는 것이 아니라, ’무슨 일이 일어났는지’를 설명하는 명시적인 데이터 조각이다.13

  • 구현: 애플리케이션에는 다양한 종류의 액션이 존재할 수 있다. lager에서는 각 액션을 고유한 struct로 정의하고, 이들을 std::variant로 묶어 단일 action 타입으로 통합하는 것이 일반적인 패턴이다. 이는 타입 안전성(type safety)을 보장하면서 다양한 액션을 효율적으로 처리할 수 있게 해준다.11

  • 역할: lager 아키텍처에서 상태를 변경할 수 있는 유일한 방법은 액션을 발행(dispatch)하는 것이다.12 직접 모델의 멤버 변수를 수정하는 행위는 금지된다. 모든 상태 변화가 액션이라는 명시적인 통로를 통해서만 일어나기 때문에, 애플리케이션에서 발생하는 모든 변화를 추적하고 기록하고 재현하는 것이 가능해진다.15

2.3 리듀서 (Reducer): 상태를 변화시키는 순수 함수

  • 정의: 리듀서(Reducer)는 상태 변화 로직을 담고 있는 순수 함수(pure function)다. 이 함수는 (현재 상태, 액션) -> 새로운 상태라는 명확한 시그니처를 갖는다.15

  • 역할: 리듀서는 dispatch된 액션과 현재 상태 값을 인자로 받아, 액션의 타입에 따라 다음 상태를 계산하여 반환한다. 여기서 가장 중요한 규칙은 리듀서가 절대로 기존 상태를 직접 수정해서는 안 된다는 것이다. 즉, 부수 효과(side effects)가 없어야 하며, 인자로 받은 상태 객체를 변경(mutate)해서는 안 된다.14 항상 변화가 적용된 새로운 상태 객체를 생성하여 반환해야 한다.

  • 순수성의 이점: 리듀서가 순수 함수라는 점은 lager 아키텍처의 강력함의 원천이다. 동일한 입력(동일한 상태와 동일한 액션)에 대해 항상 동일한 출력(동일한 새로운 상태)을 보장하기 때문에, 시스템의 동작이 지극히 예측 가능해진다. 또한, 외부 세계에 대한 의존성이 없으므로 UI나 데이터베이스 연결 없이도 핵심 비즈니스 로직을 매우 쉽게 단위 테스트할 수 있다.1

2.4 스토어 (Store): 모든 것을 연결하는 중앙 조정자

  • 정의: 스토어(lager::store)는 앞서 설명한 모델, 액션, 리듀서를 하나로 묶어주는 중앙 조정자 역할을 하는 객체다. 애플리케이션의 전체 상태 트리(state tree)를 내부에 보유하고 관리한다.12

  • 역할: 스토어는 다음과 같은 핵심적인 역할을 수행한다.

  1. 상태 관리: 애플리케이션의 현재 상태(model)를 안전하게 보관한다.

  2. 액션 전달: dispatch(action) 메서드를 제공하여 외부로부터 액션을 받아 내부의 리듀서에게 전달한다.12

  3. 상태 접근: 현재 상태에 대한 읽기 전용 접근을 제공한다 (예: 커서를 통한 get()).6

  4. 변경 알림: 상태가 변경되었을 때, 이를 구독(subscribe)하고 있는 관찰자(listener)들에게 변경 사실을 알리는 메커니즘을 제공한다 (예: watch 또는 커서를 통한 bind()).6

  • 생성: 스토어는 일반적으로 lager::make_store 팩토리 함수를 통해 생성된다. 이 함수에는 액션 타입, 초기 상태, 그리고 이벤트 루프 등이 인자로 전달되어 스토어의 동작 방식을 설정한다.11

lager의 불변성 원칙은 매 액션마다 잠재적으로 큰 상태 객체의 새로운 복사본을 만들어야 한다는 것을 의미한다. 만약 이를 순진하게 깊은 복사(deep copy)로 구현한다면, 대규모 애플리케이션에서는 심각한 성능 저하를 유발할 것이다. 바로 이 지점에서 lagerimmer 라이브러리의 공생 관계가 빛을 발한다. lager의 문서와 예제들은 immer라는 C++용 불변 데이터 구조 라이브러리의 사용을 지속적으로 권장한다.4

immer는 ’구조적 공유(structural sharing)’라는 최적화 기법을 사용한다. 새로운 버전의 데이터를 만들 때, 변경되지 않은 부분은 이전 버전의 메모리를 그대로 재사용(공유)하고, 변경이 발생한 경로에 있는 노드들만 새로 할당한다.17 이 영리한 최적화 덕분에 불변 데이터 구조의 생성 및 비교 비용이 획기적으로 줄어들어, lager가 제안하는 ’불변 상태’라는 철학이 성능 저하 없이 실용적인 대규모 애플리케이션에 적용될 수 있는 기술적 기반이 마련된다. lager가 ’무엇을 할 것인가(아키텍처)’를 정의한다면, immer는 ’어떻게 효율적으로 할 것인가(구현)’를 담당하는 핵심 파트너인 셈이다.

웹 개발 생태계, 특히 Redux에 익숙한 개발자들을 위해 lager의 구성요소를 비교하면 다음과 같다.

lager ComponentRedux Component역할 (Role)
struct modelState Object애플리케이션의 전체 상태를 나타내는 유일한 데이터 구조.
std::variant<...actions>Action Object발생한 이벤트를 기술하는 평범한 객체. type 필드를 반드시 포함.
update(model, action)Reducer Function(state, action) => newState 형태의 순수 함수.
lager::storeStore Object상태를 저장하고, 액션을 전달하며, 리스너를 관리하는 중앙 허브.
store.dispatch(action)store.dispatch(action)액션을 리듀서로 보내 상태 변화를 촉발시키는 유일한 방법.

3. 실전 예제: lager를 이용한 터미널 기반 카운터 애플리케이션 개발

lager의 아키텍처를 실제로 이해하는 가장 좋은 방법은 간단한 애플리케이션을 직접 만들어보는 것이다. 여기서는 터미널 입력을 통해 숫자를 증가, 감소, 리셋하는 기본적인 카운터 애플리케이션을 단계별로 개발한다.11

3.1 프로젝트 환경 설정

lager는 C++17 표준을 준수하는 컴파일러를 요구하는 헤더 전용 라이브러리다.1 핵심 의존성으로 zugBoost.Hana가 필요하다. 이 의존성들은 CMake를 사용하여 관리하는 것이 편리하다. FetchContent 모듈을 사용하면 빌드 시점에 자동으로 의존성을 다운로드하고 설정할 수 있다. 또는 Conan과 같은 패키지 매니저를 사용하여 시스템에 라이브러리를 미리 설치할 수도 있다.10

다음은 CMakeLists.txt의 기본적인 예시다.

cmake_minimum_required(VERSION 3.14)
project(LagerCounterExample)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

include(FetchContent)

FetchContent_Declare(
zug
GIT_REPOSITORY https://github.com/arximboldi/zug.git
GIT_TAG        master)
FetchContent_MakeAvailable(zug)

FetchContent_Declare(
lager
GIT_REPOSITORY https://github.com/arximboldi/lager.git
GIT_TAG        master)
FetchContent_MakeAvailable(lager)

add_executable(counter main.cpp)
target_link_libraries(counter PRIVATE lager::lager)

3.2 1단계: ModelAction 타입 정의

먼저, 애플리케이션의 상태와 상태를 변경할 수 있는 동작들을 값 타입으로 정의한다. 코드를 논리적으로 그룹화하기 위해 counter 네임스페이스를 사용한다.11

  • Model: 카운터의 상태는 단 하나의 정수 값이다. 이를 value 멤버를 가진 struct model로 정의한다.

  • Action: 사용자는 카운터를 증가시키거나, 감소시키거나, 특정 값으로 리셋할 수 있다. 이 세 가지 동작을 각각 increment_action, decrement_action, reset_action struct로 정의한다. reset_action은 새로운 값을 저장할 new_value 멤버를 가진다. 이들을 std::variant를 사용하여 action이라는 단일 타입으로 묶는다.

// main.cpp
#include <iostream>
#include <optional>
#include <string>
#include <variant>

namespace counter {

struct model {
int value = 0;
};

struct increment_action {};
struct decrement_action {};
struct reset_action {
int new_value = 0;
};

using action = std::variant<
increment_action,
decrement_action,
reset_action>;

} // namespace counter

3.3 2단계: Reducer 로직 구현

다음으로, (model, action) -> model 시그니처를 따르는 리듀서 함수 update를 구현한다. 이 함수는 순수 함수여야 하며, 인자로 받은 model을 직접 수정하는 대신 수정된 복사본을 반환해야 한다.11

std::visitlager::visitor 유틸리티를 사용하면 std::variant로 정의된 action의 각 타입을 타입 안전하게 처리할 수 있다.

// main.cpp (이어서)
#include <lager/util.hpp>

namespace counter {

//... (model, action 정의)

model update(model c, action action)
{
return std::visit(
lager::visitor{
[&](increment_action) {
++c.value;
return c;
},
[&](decrement_action) {
--c.value;
return c;
},
[&](reset_action%20a) {
c.value = a.new_value;
return c;
},
},
action);
}

} // namespace counter

3.4 3단계: Store 생성 및 이벤트 루프 연동

애플리케이션의 main 함수에서 lager::make_store를 호출하여 store를 생성한다. 이 예제에서는 UI 프레임워크의 이벤트 루프를 사용하는 대신, 터미널 입력을 직접 처리할 것이므로 lager::with_manual_event_loop{}를 사용한다. 이는 이벤트 루프의 제어권이 우리에게 있음을 lager에게 알리는 역할을 한다.11

// main.cpp (이어서)
#include <lager/store.hpp>
#include <lager/event_loop/manual.hpp>

int main()
{
// 초기 모델과 수동 이벤트 루프를 사용하여 스토어 생성
auto store = lager.make_store<counter::action>(
counter::model{},
update, // 리듀서 함수 전달
lager::with_manual_event_loop{});

//... (이후 로직)
}

3.5 4단계: UI 로직 연결 - Intent와 View

이제 UI와 lager의 단방향 데이터 흐름을 연결한다.

  • View: void draw(counter::model curr) 함수는 현재 모델 상태를 받아 터미널에 출력하는 역할을 한다. 이것이 우리의 ’뷰’다.11

  • Watch: lager::watch 함수를 사용하여 store를 구독한다. 이렇게 하면 store의 상태가 변경될 때마다 draw 함수가 자동으로 호출되어 UI가 업데이트된다.

  • Intent: std::optional<counter::action> intent(char event) 함수는 사용자의 원시 입력(이 경우 char)을 해석하여 lager가 이해할 수 있는 action으로 변환하는 역할을 한다. 이를 ‘인텐트’ 함수라 부른다.11

// main.cpp (counter 네임스페이스 외부, main 함수 이전)

void draw(counter::model curr)
{
std::cout << "current value: " << curr.value << std::endl;
}

std::optional<counter::action> intent(char event)
{
switch (event) {
case '+': return counter::increment_action{};
case '-': return counter::decrement_action{};
case '.': return counter::reset_action{0};
default: return std::nullopt;
}
}

// main 함수 내부에서 store 생성 후
//...
lager::watch(store, &draw);
//...

3.6 5단계: 메인 루프 구현

마지막으로, 사용자 입력을 지속적으로 받고, 이를 intent 함수로 해석한 뒤, 유효한 action이 생성되면 store.dispatch()를 통해 시스템에 전달하는 메인 루프를 구현한다. dispatch 호출은 Reducer -> State Update -> View Update로 이어지는 전체 단방향 데이터 흐름 사이클을 촉발시킨다.11

// main 함수 내부
//...
lager::watch(store, &draw);

char event = 0;
while (std::cin >> event) {
if (auto act = intent(event)) {
store.dispatch(*act);
}
}
//...

3.7 전체 소스 코드 및 실행 결과

지금까지의 모든 단계를 통합한 전체 소스 코드는 다음과 같다.

#include <iostream>
#include <optional>
#include <string>
#include <variant>

#include <lager/event_loop/manual.hpp>
#include <lager/store.hpp>
#include <lager/util.hpp>

namespace counter {

struct model
{
int value = 0;
};

struct increment_action {};
struct decrement_action {};
struct reset_action {
int new_value = 0;
};

using action = std::variant<
increment_action,
decrement_action,
reset_action>;

model update(model c, action action)
{
return std::visit(
lager::visitor{
[&](increment_action) {
++c.value;
return c;
},
[&](decrement_action) {
--c.value;
return c;
},
[&](reset_action%20a) {
c.value = a.new_value;
return c;
},
},
action);
}

} // namespace counter

void draw(counter::model curr)
{
std::cout << "current value: " << curr.value << std::endl;
}

std::optional<counter::action> intent(char event)
{
switch (event) {
case '+': return counter::increment_action{};
case '-': return counter::decrement_action{};
case '.': return counter::reset_action{0};
default: return std::nullopt;
}
}

int main()
{
auto store = lager::make_store<counter::action>(
counter::model{},
counter::update,
lager::with_manual_event_loop{});

lager::watch(store, &draw);

char event = 0;
while (std::cin >> event) {
if (auto act = intent(event)) {
store.dispatch(*act);
}
}

return 0;
}

이 코드를 컴파일하고 실행한 뒤, 터미널에서 +, +, -, . 등을 입력하면 다음과 같은 출력을 볼 수 있다.

current value: 0
+
current value: 1
+
current value: 2
-
current value: 1
.
current value: 0

4. lager 도입의 실질적 이점 분석

lager 아키텍처를 도입하는 것은 단순히 코딩 스타일을 바꾸는 것을 넘어, 소프트웨어 개발의 여러 측면에서 구체적이고 강력한 이점을 제공한다. 이러한 이점들은 lager의 핵심 설계 원칙에서 직접 비롯된다.

장점 (Advantage)기인하는 아키텍처 원칙 (Originating Architectural Principle)구체적 효과 (Concrete Effect)
예측 가능성단방향 데이터 흐름, 유일한 진실의 원천상태 변화의 원인과 결과 추적이 용이하며, 버그 발생 시 재현이 쉽다.
테스트 용이성순수 함수 (리듀서)애플리케이션의 핵심 로직을 UI나 외부 의존성 없이 독립적으로 테스트 가능.
안전한 동시성불변성, 값 의미론데이터 경쟁(Data Race) 없이 여러 스레드가 상태를 안전하게 읽을 수 있다.
강력한 디버깅상태 스냅샷’시간 여행 디버깅’을 통해 과거의 특정 상태로 돌아가 문제 분석 가능.
손쉬운 기능 추가조합 가능한 설계실행 취소/다시 실행(Undo/Redo)과 같은 복잡한 기능을 쉽게 구현.

4.1 예측 가능성과 디버깅: 시간 여행은 무료

lager 아키텍처의 가장 큰 장점은 예측 가능성이다. 모든 상태 변화는 명시적인 Action 객체와 순수 함수인 Reducer를 통해서만 발생한다.1 이는 시스템의 상태가 언제, 왜, 그리고 어떻게 변했는지 추적하는 것을 매우 쉽게 만든다. 액션 로그만 있다면, 초기 상태로부터 시작하여 특정 버그가 발생하는 시점까지의 상태 변화를 100% 정확하게 재현할 수 있다.

더 나아가 lager는 ’시간 여행 디버깅(time-travel debugging)’이라는 강력한 기능을 거의 무료로 제공한다.3

lager는 내부적으로 상태의 스냅샷을 효율적으로 관리할 수 있는 구조를 가지고 있다. 개발자는 디버깅 도구를 사용하여 애플리케이션의 실행 기록을 앞뒤로 오가며 각 액션이 상태에 어떤 영향을 미쳤는지 직접 확인할 수 있다. 이는 복잡한 상호작용 속에서 발생하는 버그의 근본 원인을 찾는 데 드는 시간을 획기적으로 단축시킨다.1

4.2 테스트 용이성: 순수함 로직의 힘

애플리케이션의 핵심 로직이 순수 함수인 리듀서에 집중되어 있다는 사실은 테스트 용이성을 극대화한다.1 리듀서는 외부 세계(파일 시스템, 네트워크, UI 등)에 대한 어떠한 의존성도 갖지 않기 때문에, 복잡한 모의 객체(mock object)나 테스트 환경 설정 없이도 매우 간단하게 단위 테스트를 작성할 수 있다.

앞서 작성한 카운터 예제의 update 함수에 대한 단위 테스트는 다음과 같이 작성할 수 있다 (Catch2 테스트 프레임워크 사용 예시).

#define CATCH_CONFIG_MAIN
#include "catch2/catch.hpp"
#include "main.cpp" // 실제 애플리케이션 코드를 포함

TEST_CASE("Reducer correctly handles actions") {
SECTION("increment action") {
auto initial_model = counter::model{0};
auto next_model = counter::update(initial_model, counter::increment_action{});
REQUIRE(next_model.value == 1);
}

SECTION("decrement action") {
auto initial_model = counter::model{5};
auto next_model = counter::update(initial_model, counter::decrement_action{});
REQUIRE(next_model.value == 4);
}

SECTION("reset action") {
auto initial_model = counter::model{10};
auto next_model = counter::update(initial_model, counter::reset_action{100});
REQUIRE(next_model.value == 100);
}
}

이처럼 테스트 코드는 단순히 초기 상태와 액션을 준비하고, 리듀서 함수를 호출한 뒤, 반환된 새로운 상태가 예상과 일치하는지만 확인하면 된다. 이는 테스트의 신뢰성을 높이고 개발자가 자신감을 갖고 코드를 리팩토링할 수 있게 해준다.

4.3 동시성: 뮤텍스와의 작별

전통적인 다중 스레드 프로그래밍의 가장 큰 골칫거리는 공유된 가변 상태를 안전하게 관리하기 위한 뮤텍스(mutex), 세마포어(semaphore) 등의 동기화 메커니즘이다. lager의 불변성 원칙은 이러한 복잡성을 상당 부분 제거한다.1

상태 객체는 결코 그 자리에서 수정되지 않으므로, 여러 스레드가 동시에 같은 상태 객체를 읽는 것은 완벽하게 안전하다. 데이터 경쟁(data race)이 발생할 여지가 원천적으로 없기 때문에 어떠한 락(lock)도 필요하지 않다.

백그라운드 스레드에서 복잡한 계산을 수행해야 할 경우, 해당 스레드는 메인 스레드의 현재 상태를 안전하게 복사하여 작업을 수행할 수 있다. 작업이 완료되면, 그 결과를 담은 새로운 action을 생성하여 메인 스레드의 이벤트 루프를 통해 storedispatch하기만 하면 된다. 이 방식은 스레드 간의 통신을 명시적인 액션 전달로 단순화하며, 복잡하고 오류가 발생하기 쉬운 동기화 코드를 애플리케이션의 핵심 로직에서 분리시킨다.1

5. 고급 주제 탐구

lager는 기본적인 상태 관리를 넘어, 실제 복잡한 애플리케이션 개발에 필요한 고급 기능과 추상화도 제공한다.

5.1 커서(Cursor): 상태 트리의 일부를 다루는 우아한 방법

애플리케이션의 상태 모델이 커지고 중첩 구조가 깊어지면, UI 컴포넌트가 전체 상태 트리(monolithic state tree)를 직접 다루는 것은 번거롭고 비효율적일 수 있다. lager::cursor는 이러한 문제를 해결하기 위한 우아한 추상화다.2

커서는 전체 상태 트리의 특정 하위 부분에 대한 ‘뷰(view)’ 또는 ‘렌즈(lens)’ 역할을 한다. 이를 통해 특정 UI 컴포넌트는 자신이 관심 있는 상태의 일부에만 집중할 수 있다. 커서는 해당 데이터 조각에 대한 읽기 접근(cursor.get())과 쓰기처럼 보이는 연산(cursor.set(...))을 제공한다.6

내부적으로 cursor.set() 호출은 전통적인 객체 지향의 세터(setter)처럼 직접 값을 변경하는 것이 아니다. 대신, 해당 값을 변경하는 적절한 action을 생성하여 store에 자동으로 dispatch하는 방식으로 동작한다.6 이는

lager의 핵심인 단방향 데이터 흐름과 불변성 원칙을 깨지 않으면서도, Qt/QML과 같은 객체 지향적 UI 프레임워크와 자연스럽게 연동할 수 있게 해주는 매우 강력한 다리 역할을 한다. 즉, 커서는 lager의 값 지향적이고 불변적인 코어와, 전통적인 객체 지향 UI 프레임워크 사이의 패러다임 불일치를 해소하는 핵심적인 추상화 계층이다.2

5.2 부수 효과(Effects): 비동기 작업과 외부 세계와의 상호작용

리듀서는 반드시 순수 함수여야 하므로, 파일 입출력, 네트워크 요청, 데이터베이스 접근과 같은 부수 효과(side effects)를 직접 수행할 수 없다.14 하지만 실제 애플리케이션은 이러한 외부 세계와의 상호작용이 필수적이다.

lager는 이러한 부수 효과를 단방향 데이터 흐름의 원칙을 해치지 않으면서 처리할 수 있는 메커니즘을 제공한다. 일반적인 패턴은 리듀서가 상태를 업데이트하는 대신, 수행해야 할 ’효과(effect)’를 기술하는 데이터(예: 네트워크 요청 URL과 파라미터)를 상태와 함께 반환하는 것이다.3

미들웨어(enhancer)나 별도의 효과 처리 시스템이 이 ‘효과’ 기술자를 감지하고, 실제 비동기 작업을 수행한다. 작업이 완료되면(성공 또는 실패), 그 결과를 담은 새로운 action을 생성하여 다시 storedispatch한다. 그러면 이 새로운 액션이 리듀서를 통해 최종적으로 애플리케이션의 상태에 반영된다. 이 방식은 Redux-Saga나 Redux-Thunk와 같은 미들웨어 패턴과 유사하며, 비동기 로직을 순수한 상태 변화 로직으로부터 명확하게 분리하여 애플리케이션의 예측 가능성과 테스트 용이성을 유지시켜 준다.

6. 결론: C++ 상태 관리의 미래와 lager

lager는 단순히 또 하나의 C++ 상태 관리 라이브러리를 넘어선다. 이것은 복잡한 대화형 C++ 애플리케이션을 설계하고 구축하는 방식을 근본적으로 바꾸는 강력한 아키텍처 패러다임을 C++ 생태계에 제시한다.

값 지향 설계, 불변성, 그리고 단방향 데이터 흐름이라는 세 가지 핵심 원칙을 통해 lager가 제공하는 예측 가능성, 테스트 용이성, 그리고 안전한 동시성은 버그가 적고, 확장 가능하며, 장기적으로 유지보수하기 쉬운 소프트웨어를 만드는 데 필수적인 가치다. 전통적인 MVC와 객체 지향 패턴이 복잡성과 씨름하며 만들어냈던 미묘하고 추적하기 어려운 버그들을 구조적으로 방지할 수 있는 길을 열어준다.

물론, 이러한 패러다임의 전환에는 초기 학습 곡선이 존재한다. 하지만 lager가 제공하는 구조적 명확성과 그로 인해 얻게 되는 개발 생산성 및 소프트웨어 품질의 향상은 그 비용을 상쇄하고도 남는다. 복잡한 상태 관리 문제에 직면한 C++ 개발자들에게 lager는 매우 매력적이고 현대적인 해결책을 제시한다. 궁극적으로 lager는 C++ 생태계가 함수형 프로그래밍 패러다임의 검증된 장점들을 어떻게 성공적으로 흡수하고 발전시켜 나가는지를 보여주는 중요한 이정표라 할 수 있다.

7. 참고 자료

  1. arximboldi/lager: C++ library for value-oriented design … - GitHub, https://github.com/arximboldi/lager
  2. Value-oriented Design in an Object-oriented System - Juan Pedro Bolivar Puente - YouTube, https://www.youtube.com/watch?v=67MmJSw4bxo
  3. Contents — lager 0.0.0 documentation, https://sinusoid.es/lager/
  4. Model — lager 0.0.0 documentation, https://sinusoid.es/lager/model.html
  5. Immutable Architecture Pattern - System Design - GeeksforGeeks, https://www.geeksforgeeks.org/system-design/immutable-architecture-pattern-system-design/
  6. Brush GUI Design with Lager — Krita Manual 5.2.0 documentation, https://docs.krita.org/sl/untranslatable_pages/brush_editor_gui_with_lager.html
  7. Exploring an Immutable Architecture - Michael L Perry, https://michaelperry.net/talks/exploring-an-immutable-architecture/
  8. Explain the concept of unidirectional data flow in MVI and its benefits. - Medium, https://medium.com/@phyothinzaraung/explain-the-concept-of-unidirectional-data-flow-in-mvi-and-its-benefits-4a366628b246
  9. Unidirectional Data Flow | Livefront Talks 2023 - YouTube, https://www.youtube.com/watch?v=ffR_KI-BkcM
  10. lager - Conan 2.0: C and C++ Open Source Package Manager, https://conan.io/center/recipes/lager
  11. Architecture — lager 0.0.0 documentation, https://sinusoid.es/lager/architecture.html
  12. Store - Redux, https://redux.js.org/api/store
  13. What is a difference between action,reducer and store in redux? - Stack Overflow, https://stackoverflow.com/questions/54385323/what-is-a-difference-between-action-reducer-and-store-in-redux
  14. Redux Fundamentals, Part 3: State, Actions, and Reducers, https://redux.js.org/tutorials/fundamentals/part-3-state-actions-reducers
  15. Actions and reducers: updating state - Human Redux, https://read.reduxbook.com/markdown/part1/03-updating-state
  16. store — lager 0.0.0 documentation, https://sinusoid.es/lager/store.html
  17. arximboldi/immer: Postmodern immutable and persistent data structures for C++ — value semantics at scale - GitHub, https://github.com/arximboldi/immer